20张图,带你搞懂高并发中的线程与线程池!
The following article is from 码农的荒岛求生 Author 码农的荒岛求生
来源 | 码农的荒岛求生
从这篇开始将会开启高性能、高并发系列,本篇是该系列的开篇,主要关注多线程以及线程池。你可能会有疑问,讲多线程为什么要从CPU说起呢?原因很简单,在这里没有那些时髦的概念,你可以更加清晰的看清问题的本质。CPU并不知道线程、进程之类的概念。CPU只知道两件事:1. 从内存中取出指令。2. 执行指令,然后回到1。
上一小节中我们明白了CPU的工作原理,我们想让CPU执行某个函数,那么只需要把函数对应的第一条机器执行装入PC寄存器就可以了,这样即使没有操作系统我们也可以让CPU执行程序,虽然可行但这是一个非常繁琐的过程,我们需要:
在内存中找到一块大小合适的区域装入程序。 找到函数入口,设置好PC寄存器让CPU开始执行程序。
struct *** {
void* start_addr;
int len;
void* start_point;
...
};
人类的一大特点就是生命不息折腾不止,从单核折腾到了多核。
进程是需要占用内存空间的(从上一节能看到这一点),如果多个进程基于同一个可执行程序,那么这些进程其内存区域中的内容几乎完全相同,这显然会造成内存的浪费。 计算机处理的任务可能是比较复杂的,这就涉及到了进程间通信,由于各个进程处于不同的内存地址空间,进程间通信天然需要借助操作系统,这就在增大编程难度的同时也增加了系统开销。
让我再来仔细的想一想这个问题,所谓进程无非就是内存中的一段区域,这段区域中保存了CPU执行的机器指令以及函数运行时的堆栈信息,要想让进程运行,就把main函数的第一条机器指令地址写入PC寄存器,这样进程就运行起来了。
另外值得注意的一点是,由于各个线程共享进程的内存地址空间,因此线程之间的通信无需借助操作系统,这给程序员带来极大方便的同时也带来了无尽的麻烦,多线程遇到的多数问题都出自于线程间通信简直太方便了以至于非常容易出错。出错的根源在于CPU执行指令时根本没有线程的概念,多线程编程面临的互斥与同步问题需要程序员自己解决,关于互斥与同步问题限于篇幅就不详细展开了,大部分的操作系统资料都有详细讲解。
最后需要提醒的是,虽然前面关于线程讲解使用的图中用了多个CPU,但不是说一定要有多核才能使用多线程,在单核的情况下一样可以创建出多个线程,原因在于线程是操作系统层面的实现,和有多少个核心是没有关系的,CPU在执行机器指令时也意识不到执行的机器指令属于哪个线程。即使在只有一个CPU的情况下,操作系统也可以通过线程调度让各个线程“同时”向前推进,方法就是将CPU的时间片在各个线程之间来回分配,这样多个线程看起来就是“同时”运行了,但实际上任意时刻还是只有一个线程在运行。在前面的讨论中我们知道了线程和CPU的关系,也就是把CPU的PC寄存器指向线程的入口函数,这样线程就可以运行起来了,这就是为什么我们创建线程时必须指定一个入口函数的原因。无论使用任何编程语言,创建一个线程大体相同:
// 设置线程入口函数DoSomething
thread = CreateThread(DoSomething);
// 让线程运行起来
thread.Run();
现在有了线程的概念,那么接下来作为程序员我们该如何使用线程呢?从生命周期的角度讲,线程要处理的任务有两类:长任务和短任务。1,长任务,long-lived tasks顾名思义,就是任务存活的时间很长,比如以我们常用的word为例,我们在word中编辑的文字需要保存在磁盘上,往磁盘上写数据就是一个任务,那么这时一个比较好的方法就是专门创建一个写磁盘的线程,该写线程的生命周期和word进程是一样的,只要打开word就要创建出该写线程,当用户关闭word时该线程才会被销毁,这就是长任务。
线程池的概念是非常简单的,无非就是创建一批线程,之后就不再释放了,有任务就提交给这些线程处理,因此无需频繁的创建、销毁线程,同时由于线程池中的线程个数通常是固定的,也不会消耗过多的内存,因此这里的思想就是复用、可控。
可能有的同学会问,该怎么给线程池提交任务呢?这些任务又是怎么给到线程池中线程呢?很显然,数据结构中的队列天然适合这种场景,提交任务的就是生产者,消费任务的线程就是消费者,实际上这就是经典的生产者-消费者问题。
struct task {
void* data; // 任务所携带的数据
handler handle; // 处理数据的方法
}
while(true) {
struct task = GetFromQueue(); // 从队列中取出数据
task->handle(task->data); // 处理数据
}
现在线程池有了,那么线程池中线程的数量该是多少呢?在接着往下看前先自己想一想这个问题。如果你能看到这里说明还没有睡着。要知道线程池的线程过少就不能充分利用CPU,线程创建的过多反而会造成系统性能下降,内存占用过多,线程切换造成的消耗等等。因此线程的数量既不能太多也不能太少,那到底该是多少呢?回答这个问题,你需要知道线程池处理的任务有哪几类,有的同学可能会说你不是说有两类吗?长任务和短任务,这个是从生命周期的角度来看的,那么从处理任务所需要的资源角度看也有两种类型,这就是没事儿找抽型和。。啊不,是CPU密集型和I/O密集型。1,CPU密集型所谓CPU密集型就是说处理任务不需要依赖外部I/O,比如科学计算、矩阵运算等等。在这种情况下只要线程的数量和核数基本相同就可以充分利用CPU资源。
线程池仅仅是多线程的一种使用形式,因此多线程面临的问题线程池同样不能避免,像死锁问题、race condition问题等等,关于这一部分同样可以参考操作系统相关资料就能得到答案,所以基础很重要呀老铁们。
线程池是程序员手中强大的武器,互联网公司的各个server上几乎都能见到线程池的身影,使用线程池前你需要考虑:
充分理解你的任务,是长任务还是短任务、是CPU密集型还是I/O密集型,如果两种都有,那么一种可能更好的办法是把这两类任务放到不同的线程池中,这样也许可以更好的确定线程数量。 如果线程池中的任务有I/O操作,那么务必对此任务设置超时,否则处理该任务的线程可能会一直阻塞下去。 线程池中的任务最好不要同步等待其它任务的结果。
本节我们从CPU开始一路来到常用的线程池,从底层到上层、从硬件到软件。注意,这里通篇没有出现任何特定的编程语言,线程不是语言层面的概念(依然不考虑用户态线程),但是当你真正理解了线程后,相信你可以在任何一门语言下用好多线程,你需要理解的是道,此后才是术。希望这篇文章对大家理解线程以及线程池有所帮助。接下的一篇将是与线程池密切配合实现高性能、高并发的又一关键技术:I/O与I/O多路复用,敬请期待。
更多阅读推荐